/***
 * @Author         : seem.sky@gmail.com
 * @Email          : seem.sky@gmail.com
 * @Description    :
 * @FilePath       : \Plugins\UnityPurchasing\iOS\UnityPurchasing.m
 * @Date           : 2024-08-05 11:32:18
 * @LastEditTime   : 2024-09-05 11:52:12
 * @LastEditors    : seem.sky@gmail.com seem.sky@gmail.com
 */
/***
 * @Author         : seem.sky@gmail.com
 * @Email          : seem.sky@gmail.com
 * @Description    :
 * @FilePath       : \Plugins\UnityPurchasing\iOS\UnityPurchasing.m
 * @Date           : 2024-08-05 11:32:18
 * @LastEditTime   : 2024-08-20 15:58:52
 * @LastEditors    : seem.sky@gmail.com seem.sky@gmail.com
 */
#import "UnityPurchasing.h"
#if MAC_APPSTORE
#import "Base64.h"
#endif

#if !MAC_APPSTORE
#import "UnityEarlyTransactionObserver.h"
#endif

@implementation ProductDefinition

@synthesize id;
@synthesize storeSpecificId;
@synthesize type;

@end

void UnityPurchasingLog(NSString *format, ...)
{
    va_list args;
    va_start(args, format);
    NSString *message = [[NSString alloc] initWithFormat: format arguments: args];
    va_end(args);

    NSLog(@"UnityIAP: %@", message);
}

@implementation ReceiptRefresher

- (id)initWithCallback:(void (^)(BOOL, NSString*))callbackBlock
{
    self.callback = callbackBlock;
    return [super init];
}

- (void)requestDidFinish:(SKRequest *)request
{
    self.callback(true, NULL);
}

- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
{
    NSString* errorMessage = [NSString stringWithFormat: @"Error code: %ld, error description: %@", error.code, error.description];
    self.callback(false, errorMessage);
}

@end

#if !MAC_APPSTORE
@interface UnityPurchasing ()<UnityEarlyTransactionObserverDelegate>
@end
#endif

@implementation UnityPurchasing

// The max time we wait in between retrying failed SKProductRequests.
static const int MAX_REQUEST_PRODUCT_RETRY_DELAY = 60;
// The currency code for unknown locales, from https://en.wikipedia.org/wiki/ISO_4217#X_currencies
static const NSString* ISO_CURRENCY_CODE_UNKNOWN = @"XXX";

// Track our accumulated delay.
int delayInSeconds = 2;

- (NSString*)getAppReceipt
{
    NSBundle* bundle = [NSBundle mainBundle];
    if ([bundle respondsToSelector: @selector(appStoreReceiptURL)])
    {
        NSURL *receiptURL = [bundle appStoreReceiptURL];
        if ([[NSFileManager defaultManager] fileExistsAtPath: [receiptURL path]])
        {
            NSData *receipt = [NSData dataWithContentsOfURL: receiptURL];

#if MAC_APPSTORE
            // The base64EncodedStringWithOptions method was only added in OSX 10.9.
            NSString* result = [receipt mgb64_base64EncodedString];
#else
            NSString* result = [receipt base64EncodedStringWithOptions: 0];
#endif

            return result;
        }
    }

    UnityPurchasingLog(@"No App Receipt found");
    return @"";
}

- (NSDate*)getAppReceiptModificationDate
{
    NSBundle* bundle = [NSBundle mainBundle];
    if ([bundle respondsToSelector: @selector(appStoreReceiptURL)])
    {
        NSURL *receiptURL = [bundle appStoreReceiptURL];
        if ([[NSFileManager defaultManager] fileExistsAtPath: [receiptURL path]])
        {
            NSDate *modDate = [[[NSFileManager defaultManager] attributesOfItemAtPath: [receiptURL path] error: nil] fileModificationDate];
            return modDate;
        }
    }

    UnityPurchasingLog(@"No App Receipt found");
    return nil;
}

- (NSString*)getTransactionReceiptForProductId:(NSString *)productId
{
    NSString *result = transactionReceipts[productId];
    if (!result)
    {
        UnityPurchasingLog(@"No Transaction Receipt found for product %@", productId);
    }
    return result ? : @"";
}

- (void)UnitySendMessage:(NSString*)subject payload:(NSString*)payload
{
    messageCallback(subject.UTF8String, payload.UTF8String, @"".UTF8String, @"".UTF8String, @"".UTF8String, false);
}

- (void)UnitySendMessage:(NSString*)subject payload:(NSString*)payload receipt:(NSString*)receipt
{
    messageCallback(subject.UTF8String, payload.UTF8String, receipt.UTF8String, @"".UTF8String, @"".UTF8String, false);
}

- (void)UnitySendMessage:(NSString*)subject payload:(NSString*)payload receipt:(NSString*)receipt transactionId:(NSString*)transactionId originalTransactionId:(NSString*)originalTransactionId isRestored:(Boolean)isRestored
{
    messageCallback(subject.UTF8String, payload.UTF8String, receipt.UTF8String, transactionId.UTF8String, originalTransactionId.UTF8String, isRestored);
}

- (void)setCallback:(UnityPurchasingCallback)callback
{
    messageCallback = callback;
}

#if !MAC_APPSTORE
- (BOOL)isiOS6OrEarlier
{
    float version = [[[UIDevice currentDevice] systemVersion] floatValue];
    return version < 7;
}

#endif

// Retrieve a receipt for the transaction, which will either
// be the old style transaction receipt on <= iOS 6,
// or the App Receipt in OSX and iOS 7+.
- (NSString*)selectReceipt:(SKPaymentTransaction*)transaction
{
#if MAC_APPSTORE || __is_target_os(xros)
    return @"";
#else
    if ([self isiOS6OrEarlier])
    {
        if (nil == transaction)
        {
            return @"";
        }
        NSString* receipt;
        receipt = [[NSString alloc] initWithData: transaction.transactionReceipt encoding: NSUTF8StringEncoding];

        return receipt;
    }
    else
    {
        return @"";
    }
#endif
}

- (void)refreshReceipt
{
    #if !MAC_APPSTORE
    if ([self isiOS6OrEarlier])
    {
        UnityPurchasingLog(@"RefreshReceipt not supported on iOS < 7!");
        return;
    }
    #endif

    self.receiptRefresher = [[ReceiptRefresher alloc] initWithCallback:^(BOOL success, NSString* errorMessage) {
        if (success)
        {
            UnityPurchasingLog(@"RefreshReceipt status %d", success);
            [self UnitySendMessage: @"onAppReceiptRefreshed" payload: [self getAppReceipt]];
        }
        else
        {
            UnityPurchasingLog(@"RefreshReceipt status %d - Error message: %@", success, errorMessage);
            [self UnitySendMessage: @"onAppReceiptRefreshFailed" payload: errorMessage];
        }
    }];
    self.refreshRequest = [[SKReceiptRefreshRequest alloc] init];
    self.refreshRequest.delegate = self.receiptRefresher;
    [self.refreshRequest start];
}

// Handle a new or restored purchase transaction by informing Unity.
- (void)onTransactionSucceeded:(SKPaymentTransaction*)transaction isRestored:(Boolean)isRestored
{
    NSString* transactionId = transaction.transactionIdentifier;
    NSString* originalTransactionId = transaction.originalTransaction.transactionIdentifier;

    // This should never happen according to Apple's docs, but it does!
    if (nil == transactionId)
    {
        // Make something up, allowing us to identifiy the transaction when finishing it.
        transactionId = [[NSUUID UUID] UUIDString];
        UnityPurchasingLog(@"Missing transaction Identifier!");
    }

    // This transaction was marked as finished, but was not cleared from the queue. Try to clear it now, then pass the error up the stack as a DuplicateTransaction
    if ([finishedTransactions containsObject: transactionId])
    {
        [[SKPaymentQueue defaultQueue] finishTransaction: transaction];
        UnityPurchasingLog(@"DuplicateTransaction error with product %@ and transactionId %@", transaction.payment.productIdentifier, transactionId);
        [self onPurchaseFailed: transaction.payment.productIdentifier reason: @"DuplicateTransaction" errorCode: @"" errorDescription: @"Duplicate transaction occurred"];
        return; // EARLY RETURN
    }

    // Item was successfully purchased or restored.
    if (nil == [pendingTransactions objectForKey: transactionId])
    {
        [pendingTransactions setObject: transaction forKey: transactionId];
    }

    [self UnitySendMessage: @"OnPurchaseSucceeded" payload: transaction.payment.productIdentifier receipt: [self selectReceipt: transaction] transactionId: transactionId originalTransactionId: originalTransactionId isRestored: isRestored];
}

// Called back by managed code when the transaction has been logged.
- (void)finishTransaction:(NSString *)transactionIdentifier hasProduct:(Boolean)hasProduct
{
    SKPaymentTransaction* transaction = [pendingTransactions objectForKey: transactionIdentifier];
    if (nil != transaction)
    {
        if (hasProduct)
        {
            UnityPurchasingLog(@"Finishing transaction %@", transactionIdentifier);
        }
        [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; // If this fails (user not logged into the store?), transaction is already removed from pendingTransactions, so future calls to finishTransaction will not retry
        [pendingTransactions removeObjectForKey: transactionIdentifier];
        [finishedTransactions addObject: transactionIdentifier];
    }
    else
    {
        UnityPurchasingLog(@"Transaction %@ not pending, nothing to finish here", transactionIdentifier);
    }
}

// Request information about our products from Apple.
- (void)requestProducts:(NSSet*)paramIds
{
    productIds = paramIds;
    UnityPurchasingLog(@"Requesting %lu products", (unsigned long)[productIds count]);
    // Start an immediate poll.
    [self initiateProductPoll: 0];
}

// Execute a product metadata retrieval request via GCD.
- (void)initiateProductPoll:(int)delayInSeconds
{
    dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds * NSEC_PER_SEC);
    dispatch_after(popTime, dispatch_get_main_queue(), ^(void) {
        UnityPurchasingLog(@"Requesting product data...");
        self->request = [[SKProductsRequest alloc] initWithProductIdentifiers: productIds];
        self->request.delegate = self;
        [self->request start];
    });
}

// Called by managed code when a user requests a purchase.
- (void)purchaseProduct:(ProductDefinition*)productDef
{
    // Look up our corresponding product.
    SKProduct* requestedProduct = [validProducts objectForKey: productDef.storeSpecificId];

    if (requestedProduct != nil)
    {
        UnityPurchasingLog(@"PurchaseProduct: %@", requestedProduct.productIdentifier);

        if ([SKPaymentQueue canMakePayments])
        {
            SKMutablePayment *payment = [SKMutablePayment paymentWithProduct: requestedProduct];

            // Modify payment request for testing ask-to-buy
            if (_simulateAskToBuyEnabled)
            {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
                if ([payment respondsToSelector: @selector(setSimulatesAskToBuyInSandbox:)])
                {
                    UnityPurchasingLog(@"Queueing payment request with simulatesAskToBuyInSandbox enabled");
                    [payment performSelector: @selector(setSimulatesAskToBuyInSandbox:) withObject: @YES];
                    //payment.simulatesAskToBuyInSandbox = YES;
                }
#pragma clang diagnostic pop
            }

            // Modify payment request with "applicationUsername" for fraud detection
            if (_applicationUsername != nil)
            {
                if ([payment respondsToSelector: @selector(setApplicationUsername:)])
                {
                    UnityPurchasingLog(@"Setting applicationUsername to %@", _applicationUsername);
                    [payment performSelector: @selector(setApplicationUsername:) withObject: _applicationUsername];
                    //payment.applicationUsername = _applicationUsername;
                }
            }

            [[SKPaymentQueue defaultQueue] addPayment: payment];
        }
        else
        {
            UnityPurchasingLog(@"PurchaseProduct: IAP Disabled");
            [self onPurchaseFailed: productDef.storeSpecificId reason: @"PurchasingUnavailable" errorCode: @"SKErrorPaymentNotAllowed" errorDescription: @"User is not authorized to make payments"];
        }
    }
    else
    {
        [self onPurchaseFailed: productDef.storeSpecificId reason: @"ItemUnavailable" errorCode: @"" errorDescription: @"Unity IAP could not find requested product"];
    }
}

// Initiate a request to Apple to restore previously made purchases.
- (void)restorePurchases
{
    UnityPurchasingLog(@"RestorePurchase");
    [[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
}

// A transaction observer should be added at startup (by managed code)
// and maintained for the life of the app, since transactions can
// be delivered at any time.
- (void)addTransactionObserver
{
    SKPaymentQueue* defaultQueue = [SKPaymentQueue defaultQueue];

    // Detect whether an existing transaction observer is in place.
    // An existing observer will have processed any transactions already pending,
    // so when we add our own storekit will not call our updatedTransactions handler.
    // We workaround this by explicitly processing any existing transactions if they exist.
    BOOL processExistingTransactions = false;
    if (defaultQueue != nil && defaultQueue.transactions != nil)
    {
        if ([[defaultQueue transactions] count] > 0)
        {
            processExistingTransactions = true;
        }
    }

    [defaultQueue addTransactionObserver: self];
    if (processExistingTransactions)
    {
        [self paymentQueue: defaultQueue updatedTransactions: defaultQueue.transactions];
    }

#if !MAC_APPSTORE
    UnityEarlyTransactionObserver *observer = [UnityEarlyTransactionObserver defaultObserver];
    if (observer)
    {
        observer.readyToReceiveTransactionUpdates = YES;
        if (self.interceptPromotionalPurchases)
        {
            observer.delegate = self;
        }
        else
        {
            [observer initiateQueuedPayments];
        }
    }
#endif
}

- (void)initiateQueuedEarlyTransactionObserverPayments
{
#if !MAC_APPSTORE
    [[UnityEarlyTransactionObserver defaultObserver] initiateQueuedPayments];
#endif
}

- (void)presentCodeRedemptionSheet
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 && TARGET_OS_TV == 0 && !MAC_APPSTORE
    if (@available(iOS 14, *))
    {
        [[SKPaymentQueue defaultQueue] presentCodeRedemptionSheet];
    }
    else
#endif
    {
        UnityPurchasingLog(@"Offer Code redemption is available on iOS and iPadOS 14 and later");
    }
}

#if !MAC_APPSTORE
#pragma mark -
#pragma mark UnityEarlyTransactionObserverDelegate Methods

- (void)promotionalPurchaseAttempted:(SKPayment *)payment
{
    UnityPurchasingLog(@"Promotional purchase attempted");
    [self UnitySendMessage: @"onPromotionalPurchaseAttempted" payload: payment.productIdentifier];
}

#endif

#pragma mark -
#pragma mark SKProductsRequestDelegate Methods

// Store Kit returns a response from an SKProductsRequest.
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
{
    if ([response.products count] == 0)
    {
        delayInSeconds = MIN(MAX_REQUEST_PRODUCT_RETRY_DELAY, 2 * delayInSeconds);
        UnityPurchasingLog(@"SKProductRequest::didReceiveResponse: 0 products. Unity Purchasing will retry in %i seconds", delayInSeconds);
        [self initiateProductPoll: delayInSeconds];
        return;
    }

    UnityPurchasingLog(@"Received %lu products", (unsigned long)[response.products count]);
    // Add the retrieved products to our set of valid products.
    NSDictionary* fetchedProducts = [NSDictionary dictionaryWithObjects: response.products forKeys: [response.products valueForKey: @"productIdentifier"]];
    [validProducts addEntriesFromDictionary: fetchedProducts];

    NSString* productJSON = [UnityPurchasing serializeProductMetadata: response.products];

    // Send the app receipt as a separate parameter to avoid JSON parsing a large string.
    [self UnitySendMessage: @"OnProductsRetrieved" payload: productJSON];
}

#pragma mark -
#pragma mark SKPaymentTransactionObserver Methods
// A product metadata retrieval request failed.
// We handle it by retrying at an exponentially increasing interval.
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
{
    delayInSeconds = MIN(MAX_REQUEST_PRODUCT_RETRY_DELAY, 2 * delayInSeconds);
    UnityPurchasingLog(@"SKProductRequest::didFailWithError: %ld, %@. Unity Purchasing will retry in %i seconds", (long)error.code, error.description, delayInSeconds);

    [self initiateProductPoll: delayInSeconds];
}

- (void)requestDidFinish:(SKRequest *)req
{
    request = nil;
}

- (void)onPurchaseFailed:(NSString*)productId reason:(NSString*)reason errorCode:(NSString*)errorCode errorDescription:(NSString*)errorDescription
{
    NSMutableDictionary* dic = [[NSMutableDictionary alloc] init];
    [dic setObject: productId forKey: @"productId"];
    [dic setObject: reason forKey: @"reason"];
    [dic setObject: errorCode forKey: @"storeSpecificErrorCode"];
    [dic setObject: errorDescription forKey: @"message"];

    NSData* data = [NSJSONSerialization dataWithJSONObject: dic options: 0 error: nil];
    NSString* result = [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding];

    [self UnitySendMessage: @"OnPurchaseFailed" payload: result];
}

- (NSString*)purchaseErrorCodeToReason:(NSInteger)errorCode
{
    switch (errorCode)
    {
        case SKErrorPaymentCancelled:
            return @"UserCancelled";
        case SKErrorPaymentInvalid:
            return @"PaymentDeclined";
        case SKErrorPaymentNotAllowed:
            return @"PurchasingUnavailable";
    }

    return @"Unknown";
}

// The transaction status of the SKPaymentQueue is sent here.
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
    UnityPurchasingLog(@"UpdatedTransactions");
    for (SKPaymentTransaction *transaction in transactions)
    {
        [self handleTransaction: transaction];
    }
}

// Called when one or more transactions have been removed from the queue.
- (void)paymentQueue:(SKPaymentQueue *)queue removedTransactions:(NSArray *)transactions
{
    // Nothing to do here.
}

// Called when SKPaymentQueue has finished sending restored transactions.
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue
{
    UnityPurchasingLog(@"PaymentQueueRestoreCompletedTransactionsFinished");
    [self UnitySendMessage: @"onTransactionsRestoredSuccess" payload: @""];
}

// Called if an error occurred while restoring transactions.
- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error
{
    UnityPurchasingLog(@"restoreCompletedTransactionsFailedWithError");
    // Restore was cancelled or an error occurred, so notify user.

    [self UnitySendMessage: @"onTransactionsRestoredFail" payload: error.localizedDescription];
}

// Called when an entitlement was revoked.
- (void)paymentQueue:(SKPaymentQueue *)queue didRevokeEntitlementsForProductIdentifiers:(NSArray *)productIdentifiers
{
    UnityPurchasingLog(@"didRevokeEntitlementsForProductIdentifiers");
    NSString* productIdsJSON = [UnityPurchasing serializeProductIdList: productIdentifiers];

    [self UnitySendMessage: @"onEntitlementsRevoked" payload: productIdsJSON];
}

- (void)handleTransaction:(SKPaymentTransaction *)transaction
{
    if (transaction.payment.productIdentifier == nil)
    {
        return;
    }

    SKProduct* product = [validProducts objectForKey: transaction.payment.productIdentifier];

    switch (transaction.transactionState)
    {
        case SKPaymentTransactionStatePurchasing:
            // Item is still in the process of being purchased
            break;
        case SKPaymentTransactionStatePurchased:
            [self handleTransactionPurchased: transaction forProduct: product];
            break;
        case SKPaymentTransactionStateRestored:
            [self handleTransactionRestored: transaction forProduct: product];
            break;
        case SKPaymentTransactionStateDeferred:
            [self handleTransactionDeferred: transaction forProduct: product];
            break;
        case SKPaymentTransactionStateFailed:
            [self handleTransactionFailed: transaction];
            break;
    }
}

- (void)handleTransactionPurchased:(SKPaymentTransaction*)transaction forProduct:(SKProduct*)product
{
#if MAC_APPSTORE || __is_target_os(xros)
    // There is no transactionReceipt on Mac
    NSString* receipt = @"";
#else
    // The transactionReceipt field is deprecated, but is being used here to validate Ask-To-Buy purchases
    NSString* receipt = [transaction.transactionReceipt base64EncodedStringWithOptions: 0];
#endif

    transactionReceipts[transaction.payment.productIdentifier] = receipt;

    if (product != nil)
    {
        [self onTransactionSucceeded: transaction isRestored: false];
    }
}

- (void)handleTransactionRestored:(SKPaymentTransaction*)transaction forProduct:(SKProduct*)product
{
    if (product != nil)
    {
        [self onTransactionSucceeded: transaction isRestored: true];
    }
}

- (void)handleTransactionDeferred:(SKPaymentTransaction*)transaction forProduct:(SKProduct*)product
{
    if (product != nil)
    {
        UnityPurchasingLog(@"PurchaseDeferred");
        [self UnitySendMessage: @"onProductPurchaseDeferred" payload: transaction.payment.productIdentifier];
    }
}

- (void)handleTransactionFailed:(SKPaymentTransaction*)transaction
{
    // Purchase was either cancelled by user or an error occurred.
    NSString* errorCode = [NSString stringWithFormat: @"%ld", (long)transaction.error.code];
    UnityPurchasingLog(@"PurchaseFailed: %@", errorCode);

    NSString* reason = [self purchaseErrorCodeToReason: transaction.error.code];
    NSString* errorCodeString = [UnityPurchasing storeKitErrorCodeNames][@(transaction.error.code)];
    if (errorCodeString == nil)
    {
        errorCodeString = [NSString stringWithFormat: @"%ld", transaction.error.code];
    }
    NSString* errorDescription = [NSString stringWithFormat: @"APPLE_%@  - Code : %@", transaction.error.localizedDescription, errorCode];
    [self onPurchaseFailed: transaction.payment.productIdentifier reason: reason errorCode: errorCodeString errorDescription: errorDescription];

    // Finished transactions should be removed from the payment queue.
    [[SKPaymentQueue defaultQueue] finishTransaction: transaction];
}

- (void)fetchStorePromotionOrder
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 && !__is_target_os(xros)
    if (@available(iOS 11.0, *))
    {
        [[SKProductStorePromotionController defaultController] fetchStorePromotionOrderWithCompletionHandler:^(NSArray<SKProduct *> * _Nonnull storePromotionOrder, NSError * _Nullable error) {
            if (error)
            {
                UnityPurchasingLog(@"Error in fetchStorePromotionOrder: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]);

                [self UnitySendMessage: @"onFetchStorePromotionOrderFailed" payload: nil];
            }
            else
            {
                UnityPurchasingLog(@"Fetched %lu store-promotion ordered products", (unsigned long)[storePromotionOrder count]);

                NSString *productIdsJSON = [UnityPurchasing serializeSKProductIdList: storePromotionOrder];

                [self UnitySendMessage: @"onFetchStorePromotionOrderSucceeded" payload: productIdsJSON];
            }
        }];
    }
    else
#endif
    {
        UnityPurchasingLog(@"Fetch store promotion order is only available on iOS and tvOS 11 or later");
        [self UnitySendMessage: @"onFetchStorePromotionOrderFailed" payload: nil];
    }
}

- (void)updateStorePromotionOrder:(NSArray*)productIds
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 && !__is_target_os(xros)
    if (@available(iOS 11_0, *))
    {
        NSMutableArray* products = [[NSMutableArray alloc] init];

        for (NSString* productId in productIds)
        {
            SKProduct* product = [validProducts objectForKey: productId];
            if (product)
                [products addObject: product];
        }

        SKProductStorePromotionController* controller = [SKProductStorePromotionController defaultController];
        [controller updateStorePromotionOrder: products completionHandler:^(NSError* error) {
            if (error)
                UnityPurchasingLog(@"Error in updateStorePromotionOrder: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]);
        }];
    }
    else
#endif
    {
        UnityPurchasingLog(@"Update store promotion order is only available on iOS and tvOS 11 or later");
    }
}

- (void)fetchStorePromotionVisibilityForProduct:(NSString*)productId
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 && !__is_target_os(xros)
    if (@available(iOS 11.0, macOS 11.0, tvOS 11.0, *))
    {
        SKProduct *product = [validProducts objectForKey: productId];

        [[SKProductStorePromotionController defaultController]
         fetchStorePromotionVisibilityForProduct: product completionHandler:^(SKProductStorePromotionVisibility storePromotionVisibility, NSError * _Nullable error) {
            if (error)
            {
                UnityPurchasingLog(@"Error in fetchStorePromotionVisibilityForProduct: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]);

                [self UnitySendMessage: @"onFetchStorePromotionVisibilityFailed" payload: nil];
            }
            else
            {
                NSString *visibility = [UnityPurchasing getStringForStorePromotionVisibility: storePromotionVisibility];

                UnityPurchasingLog(@"Fetched Store Promotion Visibility for %@", product.productIdentifier);

                NSString *payload = [UnityPurchasing serializeVisibilityResultForProduct: productId withVisiblity: visibility];

                [self UnitySendMessage: @"onFetchStorePromotionVisibilitySucceeded" payload: payload];
            }
        }
        ];
    }
    else
#endif
    {
        UnityPurchasingLog(@"Fetch store promotion visibility is only available on iOS, macOS and tvOS 11 or later");
        [self UnitySendMessage: @"onFetchStorePromotionVisibilityFailed" payload: nil];
    }
}

// visibility should be one of "Default", "Hide", or "Show"
- (void)updateStorePromotionVisibility:(NSString*)visibility forProduct:(NSString*)productId
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 && !__is_target_os(xros)
    if (@available(iOS 11_0, *))
    {
        SKProduct *product = [validProducts objectForKey: productId];
        if (!product)
        {
            UnityPurchasingLog(@"updateStorePromotionVisibility unable to find product %@", productId);
            return;
        }

        SKProductStorePromotionVisibility v = SKProductStorePromotionVisibilityDefault;
        if ([visibility isEqualToString: @"Hide"])
            v = SKProductStorePromotionVisibilityHide;
        else if ([visibility isEqualToString: @"Show"])
            v = SKProductStorePromotionVisibilityShow;

        SKProductStorePromotionController* controller = [SKProductStorePromotionController defaultController];
        [controller updateStorePromotionVisibility: v forProduct: product completionHandler:^(NSError* error) {
            if (error)
                UnityPurchasingLog(@"Error in updateStorePromotionVisibility: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]);
        }];
    }
    else
#endif
    {
        UnityPurchasingLog(@"Update store promotion visibility is only available on iOS and tvOS 11 or later");
    }
}

- (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *)payment forProduct:(SKProduct *)product
{
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
    if (@available(iOS 11_0, *))
    {
        // Just defer to the early transaction observer. This should have no effect, just return whatever the observer returns.
        return [[UnityEarlyTransactionObserver defaultObserver] paymentQueue: queue shouldAddStorePayment: payment forProduct: product];
    }
#endif
    return YES;
}

#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 && !__is_target_os(xros)
+ (NSString*)getStringForStorePromotionVisibility:(SKProductStorePromotionVisibility)storePromotionVisibility
    API_AVAILABLE(macos(11.0), ios(11.0), tvos(11.0))
{
    switch (storePromotionVisibility)
    {
        case SKProductStorePromotionVisibilityShow:
            return @"Show";
        case SKProductStorePromotionVisibilityHide:
            return @"Hide";
        case SKProductStorePromotionVisibilityDefault:
            return @"Default";
        default:
            return @"Default";
    }
}
#endif

+ (ProductDefinition*)decodeProductDefinition:(NSDictionary*)hash
{
    ProductDefinition* product = [[ProductDefinition alloc] init];
    product.id = [hash objectForKey: @"id"];
    product.storeSpecificId = [hash objectForKey: @"storeSpecificId"];
    product.type = [hash objectForKey: @"type"];
    return product;
}

+ (NSArray*)deserializeProductDefs:(NSString*)json
{
    NSData* data = [json dataUsingEncoding: NSUTF8StringEncoding];
    NSArray* hashes = [NSJSONSerialization JSONObjectWithData: data options: 0 error: nil];

    NSMutableArray* result = [[NSMutableArray alloc] init];
    for (NSDictionary* hash in hashes)
    {
        [result addObject: [self decodeProductDefinition: hash]];
    }

    return result;
}

+ (ProductDefinition*)deserializeProductDef:(NSString*)json
{
    NSData* data = [json dataUsingEncoding: NSUTF8StringEncoding];
    NSDictionary* hash = [NSJSONSerialization JSONObjectWithData: data options: 0 error: nil];
    return [self decodeProductDefinition: hash];
}

+ (NSString*)serializeProductMetadata:(NSArray*)appleProducts
{
    NSMutableArray* hashes = [[NSMutableArray alloc] init];
    for (id product in appleProducts)
    {
        if (NULL == [product productIdentifier])
        {
            UnityPurchasingLog(@"Product is missing an identifier!");
            continue;
        }

        NSMutableDictionary* hash = [[NSMutableDictionary alloc] init];
        [hashes addObject: hash];

        [hash setObject: [product productIdentifier] forKey: @"storeSpecificId"];

        NSMutableDictionary* metadata = [[NSMutableDictionary alloc] init];
        [hash setObject: metadata forKey: @"metadata"];

        if (NULL != [product price])
        {
            [metadata setObject: [product price] forKey: @"localizedPrice"];
        }

        if (NULL != [product priceLocale])
        {
            NSString *currencyCode = [[product priceLocale] objectForKey: NSLocaleCurrencyCode];
            // NSLocaleCurrencyCode has been seen to return nil. Avoid crashing and report the issue to the log. E.g. https://developer.apple.com/forums/thread/119838
            if (currencyCode != nil)
            {
                [metadata setObject: currencyCode forKey: @"isoCurrencyCode"];
            }
            else
            {
                UnityPurchasingLog(@"Error: unable to determine localized currency code for product {%@}, [SKProduct priceLocale] identifier {%@}. NSLocaleCurrencyCode {%@} is nil. Using ISO Unknown Currency code, instead: {%@}.", [product productIdentifier], [[product priceLocale] localeIdentifier], currencyCode, ISO_CURRENCY_CODE_UNKNOWN);
                [metadata setObject: ISO_CURRENCY_CODE_UNKNOWN forKey: @"isoCurrencyCode"];
            }
        }

#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 || __TV_OS_VERSION_MAX_ALLOWED >= 140000 || __MAC_OS_X_VERSION_MAX_ALLOWED >= 110000
        if (@available(iOS 14, macOS 11.0, tvOS 14, *))
        {
            [product isFamilyShareable] ? [metadata setObject: @"true" forKey: @"isFamilyShareable"] : [metadata setObject: @"false" forKey: @"isFamilyShareable"];
        }
#endif

#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 || __TV_OS_VERSION_MAX_ALLOWED >= 110000 || __MAC_OS_X_VERSION_MAX_ALLOWED >= 101300
        if ((@available(iOS 11_2, macOS 10_13_2, tvOS 11_2, *)) && (nil != [product introductoryPrice]))
        {
            [metadata setObject: [[product introductoryPrice] price] forKey: @"introductoryPrice"];
            if (nil != [[product introductoryPrice] priceLocale])
            {
                NSString *currencyCode = [[[product introductoryPrice] priceLocale] objectForKey: NSLocaleCurrencyCode];
                [metadata setObject: currencyCode forKey: @"introductoryPriceLocale"];
            }
            else
            {
                [metadata setObject: @"" forKey: @"introductoryPriceLocale"];
            }
            if (nil != [[product introductoryPrice] numberOfPeriods])
            {
                NSNumber *numberOfPeriods = [NSNumber numberWithInt: [[product introductoryPrice] numberOfPeriods]];
                [metadata setObject: numberOfPeriods forKey: @"introductoryPriceNumberOfPeriods"];
            }
            else
            {
                [metadata setObject: @"" forKey: @"introductoryPriceNumberOfPeriods"];
            }
            if (nil != [[product introductoryPrice] subscriptionPeriod])
            {
                if (nil != [[[product introductoryPrice] subscriptionPeriod] numberOfUnits])
                {
                    NSNumber *numberOfUnits = [NSNumber numberWithInt: [[[product introductoryPrice] subscriptionPeriod] numberOfUnits]];
                    [metadata setObject: numberOfUnits forKey: @"numberOfUnits"];
                }
                else
                {
                    [metadata setObject: @"" forKey: @"numberOfUnits"];
                }
                if (nil != [[[product introductoryPrice] subscriptionPeriod] unit])
                {
                    NSNumber *unit = [NSNumber numberWithInt: [[[product introductoryPrice] subscriptionPeriod] unit]];
                    [metadata setObject: unit forKey: @"unit"];
                }
                else
                {
                    [metadata setObject: @"" forKey: @"unit"];
                }
            }
            else
            {
                [metadata setObject: @"" forKey: @"numberOfUnits"];
                [metadata setObject: @"" forKey: @"unit"];
            }
        }
        else
        {
            [metadata setObject: @"" forKey: @"introductoryPrice"];
            [metadata setObject: @"" forKey: @"introductoryPriceLocale"];
            [metadata setObject: @"" forKey: @"introductoryPriceNumberOfPeriods"];
            [metadata setObject: @"" forKey: @"numberOfUnits"];
            [metadata setObject: @"" forKey: @"unit"];
        }

        if ((@available(iOS 11_2, macOS 10_13_2, tvOS 11_2, *)) && (nil != [product subscriptionPeriod]))
        {
            if (nil !=  [[product subscriptionPeriod] numberOfUnits])
            {
                NSNumber *numberOfUnits = [NSNumber numberWithInt: [[product subscriptionPeriod] numberOfUnits]];
                [metadata setObject: numberOfUnits forKey: @"subscriptionNumberOfUnits"];
            }
            else
            {
                [metadata setObject: @"" forKey: @"subscriptionNumberOfUnits"];
            }
            if (nil != [[product subscriptionPeriod] unit])
            {
                NSNumber *unit = [NSNumber numberWithInt: [[product subscriptionPeriod] unit]];
                [metadata setObject: unit forKey: @"subscriptionPeriodUnit"];
            }
            else
            {
                [metadata setObject: @"" forKey: @"subscriptionPeriodUnit"];
            }
        }
        else
        {
            [metadata setObject: @"" forKey: @"subscriptionNumberOfUnits"];
            [metadata setObject: @"" forKey: @"subscriptionPeriodUnit"];
        }
#else
        [metadata setObject: @"" forKey: @"introductoryPrice"];
        [metadata setObject: @"" forKey: @"introductoryPriceLocale"];
        [metadata setObject: @"" forKey: @"introductoryPriceNumberOfPeriods"];
        [metadata setObject: @"" forKey: @"numberOfUnits"];
        [metadata setObject: @"" forKey: @"unit"];
        [metadata setObject: @"" forKey: @"subscriptionNumberOfUnits"];
        [metadata setObject: @"" forKey: @"subscriptionPeriodUnit"];
#endif


        NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init];
        [numberFormatter setFormatterBehavior: NSNumberFormatterBehavior10_4];
        [numberFormatter setNumberStyle: NSNumberFormatterCurrencyStyle];
        [numberFormatter setLocale: [product priceLocale]];
        NSString *formattedString = [numberFormatter stringFromNumber: [product price]];

        if (NULL == formattedString)
        {
            UnityPurchasingLog(@"Unable to format a localized price");
            [metadata setObject: @"" forKey: @"localizedPriceString"];
        }
        else
        {
            [metadata setObject: formattedString forKey: @"localizedPriceString"];
        }
        if (NULL == [product localizedTitle])
        {
            UnityPurchasingLog(@"No localized title for: %@. Have your products been disapproved in itunes connect?", [product productIdentifier]);
            [metadata setObject: @"" forKey: @"localizedTitle"];
        }
        else
        {
            [metadata setObject: [product localizedTitle] forKey: @"localizedTitle"];
        }

        if (NULL == [product localizedDescription])
        {
            UnityPurchasingLog(@"No localized description for: %@. Have your products been disapproved in itunes connect?", [product productIdentifier]);
            [metadata setObject: @"" forKey: @"localizedDescription"];
        }
        else
        {
            [metadata setObject: [product localizedDescription] forKey: @"localizedDescription"];
        }
    }


    NSData *data = [NSJSONSerialization dataWithJSONObject: hashes options: 0 error: nil];
    return [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding];
}

+ (NSString*)serializeSKProductIdList:(NSArray<SKProduct *> *)products
{
    NSMutableArray *productIds = [NSMutableArray arrayWithCapacity: products.count];
    for (SKProduct *product in products)
    {
        [productIds addObject: product.productIdentifier];
    }

    return [UnityPurchasing serializeProductIdList: products];
}

+ (NSString*)serializeProductIdList:(NSArray*)products
{
    NSData *data = [NSJSONSerialization dataWithJSONObject: products options: 0 error: nil];
    return [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding];
}

+ (NSArray*)deserializeProductIdList:(NSString*)json
{
    NSData* data = [json dataUsingEncoding: NSUTF8StringEncoding];
    NSDictionary* dict = [NSJSONSerialization JSONObjectWithData: data options: 0 error: nil];
    return [[dict objectForKey: @"products"] copy];
}

+ (NSString*)serializeVisibilityResultForProduct:(NSString *)productId withVisiblity:(NSString *)visibility
{
    NSDictionary *result = @{@"productId": productId, @"visibility": visibility};

    NSData *data = [NSJSONSerialization dataWithJSONObject: result options: 0 error: nil];
    return [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding];
}

// Note: this will need to be updated if Apple ever adds more StoreKit error codes.
+ (NSDictionary<NSNumber *, NSString *> *)storeKitErrorCodeNames
{
    return @{
            @0: @"SKErrorUnknown",
            @1: @"SKErrorClientInvalid",
            @2: @"SKErrorPaymentCancelled",
            @3: @"SKErrorPaymentInvalid",
            @4: @"SKErrorPaymentNotAllowed",
            @5: @"SKErrorStoreProductNotAvailable",
            @6: @"SKErrorCloudServicePermissionDenied",
            @7: @"SKErrorCloudServiceNetworkConnectionFailed",
            @8: @"SKErrorCloudServiceRevoked",
            @9: @"SKErrorPrivacyAcknowledgementRequired",
            @10: @"SKErrorUnauthorizedRequestData",
            @11: @"SKErrorInvalidOfferIdentifier",
            @12: @"SKErrorInvalidSignature",
            @13: @"SKErrorMissingOfferParams",
            @14: @"SKErrorInvalidOfferPrice",
            @15: @"SKErrorOverlayCancelled",
            @16: @"SKErrorOverlayInvalidConfiguration",
            @17: @"SKErrorOverlayTimeout",
            @18: @"SKErrorIneligibleForOffer",
            @19: @"SKErrorUnsupportedPlatform",
            @20: @"SKErrorOverlayPresentedInBackgroundScene",
    };
}

#pragma mark - Internal Methods & Events

- (id)init
{
    if (self = [super init])
    {
        validProducts = [[NSMutableDictionary alloc] init];
        pendingTransactions = [[NSMutableDictionary alloc] init];
        finishedTransactions = [[NSMutableSet alloc] init];
        transactionReceipts = [[NSMutableDictionary alloc] init];
    }
    return self;
}

@end

UnityPurchasing* UnityPurchasing_instance = NULL;

UnityPurchasing* UnityPurchasing_getInstance()
{
    if (NULL == UnityPurchasing_instance)
    {
        UnityPurchasing_instance = [[UnityPurchasing alloc] init];
    }
    return UnityPurchasing_instance;
}

// Make a heap allocated copy of a string.
// This is suitable for passing to managed code,
// which will free the string when it is garbage collected.
// Stack allocated variables must not be returned as results
// from managed to native calls.
char* UnityPurchasingMakeHeapAllocatedStringCopy(NSString* string)
{
    if (NULL == string)
    {
        return NULL;
    }
    char* res = (char*)malloc([string length] + 1);
    strcpy(res, [string UTF8String]);
    return res;
}

void setUnityPurchasingCallback(UnityPurchasingCallback callback)
{
    [UnityPurchasing_getInstance() setCallback: callback];
}

void unityPurchasingRetrieveProducts(const char* json)
{
    NSString* str = [NSString stringWithUTF8String: json];
    NSArray* productDefs = [UnityPurchasing deserializeProductDefs: str];
    NSMutableSet* productIds = [[NSMutableSet alloc] init];
    for (ProductDefinition* product in productDefs)
    {
        [productIds addObject: product.storeSpecificId];
    }
    [UnityPurchasing_getInstance() requestProducts: productIds];
}

void unityPurchasingPurchase(const char* json, const char* developerPayload)
{
    NSString* str = [NSString stringWithUTF8String: json];
    ProductDefinition* product = [UnityPurchasing deserializeProductDef: str];
    [UnityPurchasing_getInstance() purchaseProduct: product];
}

void unityPurchasingFinishTransaction(const char* productJSON, const char* transactionId)
{
    if (transactionId == NULL)
        return;
    Boolean hasProduct = productJSON != NULL;
    NSString* tranId = [NSString stringWithUTF8String: transactionId];
    [UnityPurchasing_getInstance() finishTransaction: tranId hasProduct: hasProduct];
}

void unityPurchasingRestoreTransactions()
{
    UnityPurchasingLog(@"Restore transactions");
    [UnityPurchasing_getInstance() restorePurchases];
}

void unityPurchasingAddTransactionObserver()
{
    UnityPurchasingLog(@"Add transaction observer");
    [UnityPurchasing_getInstance() addTransactionObserver];
}

void unityPurchasingRefreshAppReceipt()
{
    UnityPurchasingLog(@"Refresh app receipt");
    [UnityPurchasing_getInstance() refreshReceipt];
}

char* getUnityPurchasingAppReceipt()
{
    @autoreleasepool {
        NSString* receipt = [UnityPurchasing_getInstance() getAppReceipt];
        return UnityPurchasingMakeHeapAllocatedStringCopy(receipt);
    }
}

double getUnityPurchasingAppReceiptModificationDate()
{
    NSDate* receiptModificationDate = [UnityPurchasing_getInstance() getAppReceiptModificationDate];
    return receiptModificationDate.timeIntervalSince1970;
}

char* getUnityPurchasingTransactionReceiptForProductId(const char *productId)
{
    NSString* receipt = [UnityPurchasing_getInstance() getTransactionReceiptForProductId: [NSString stringWithUTF8String: productId]];
    return UnityPurchasingMakeHeapAllocatedStringCopy(receipt);
}

BOOL getUnityPurchasingCanMakePayments()
{
    return [SKPaymentQueue canMakePayments];
}

void setSimulateAskToBuy(BOOL enabled)
{
    UnityPurchasingLog(@"Set simulate Ask To Buy %@", enabled ? @"true" : @"false");
    UnityPurchasing_getInstance().simulateAskToBuyEnabled = enabled;
}

BOOL getSimulateAskToBuy()
{
    return UnityPurchasing_getInstance().simulateAskToBuyEnabled;
}

void unityPurchasingSetApplicationUsername(const char *username)
{
    if (username == NULL)
        return;
    UnityPurchasing_getInstance().applicationUsername = [NSString stringWithUTF8String: username];
}

void unityPurchasingFetchStorePromotionOrder(void)
{
    [UnityPurchasing_getInstance() fetchStorePromotionOrder];
}

void unityPurchasingFetchStorePromotionVisibility(const char *productId)
{
    NSString* prodId = [NSString stringWithUTF8String: productId];
    [UnityPurchasing_getInstance() fetchStorePromotionVisibilityForProduct: prodId];
}

// Expects json in this format:
// { "products": ["storeSpecificId1", "storeSpecificId2"] }
void unityPurchasingUpdateStorePromotionOrder(const char *json)
{
    NSString* str = [NSString stringWithUTF8String: json];
    NSArray* productIds = [UnityPurchasing deserializeProductIdList: str];
    [UnityPurchasing_getInstance() updateStorePromotionOrder: productIds];
}

void unityPurchasingUpdateStorePromotionVisibility(const char *productId, const char *visibility)
{
    NSString* prodId = [NSString stringWithUTF8String: productId];
    NSString* visibilityStr = [NSString stringWithUTF8String: visibility];
    [UnityPurchasing_getInstance() updateStorePromotionVisibility: visibilityStr forProduct: prodId];
}

void unityPurchasingInterceptPromotionalPurchases()
{
    UnityPurchasingLog(@"Intercept promotional purchases");
    UnityPurchasing_getInstance().interceptPromotionalPurchases = YES;
}

void unityPurchasingContinuePromotionalPurchases()
{
    UnityPurchasingLog(@"Continue promotional purchases");
    [UnityPurchasing_getInstance() initiateQueuedEarlyTransactionObserverPayments];
}

void unityPurchasingPresentCodeRedemptionSheet()
{
    UnityPurchasingLog(@"Present code redemption sheet");
    [UnityPurchasing_getInstance() presentCodeRedemptionSheet];
}
